Micron Document
🎖️GitЯра🎖️

Commit 9fa9f1e341ee8d2c8fd9fabf9253b2f012fc8c95


Parents : b4cc360
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T19:11:44Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T19:11:44Z

feat(analytics): report key user interactions as named RUM actions (#6654)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Changes

16 files changed, 74 insertions(+), 3 deletions(-)


Diff

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
index 32989f3f5c..3ac771cb31 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
@@ -275,6 +275,11 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes)
}
+ override fun trackAction(name: String, attributes: Map<String, Any>) {
+ if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
+ GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, name, attributes)
+ }
+
override fun startScreenView(key: String, name: String) {
if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return
GlobalRumMonitor.get().startView(key = key, name = name)

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt
index 9f82630d60..733c70c7a5 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt
@@ -52,6 +52,17 @@ interface PlatformAnalytics {
// Default no-op for platforms that don't support RUM (fdroid, desktop)
}
+ /**
+ * Tracks a key user interaction as a named custom RUM action. Auto-tracked tap targets are R8-obfuscated in release
+ * builds, so analytically important interactions must be reported explicitly with a stable name.
+ *
+ * @param name Stable snake_case action name (e.g. "message_send", "traceroute_request").
+ * @param attributes Optional structured attributes attached to the action.
+ */
+ fun trackAction(name: String, attributes: Map<String, Any> = emptyMap()) {
+ // Default no-op for platforms that don't support RUM (fdroid, desktop)
+ }
+
/**
* Starts tracking a screen as a RUM view, aligned with the Meshtastic-Apple Datadog integration (which auto-tracks
* SwiftUI views) so per-screen RUM data lines up across platforms.

diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
index d6fcb69efb..0d5c43d388 100644
--- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt
@@ -62,6 +62,13 @@ internal class MessagingControllerImpl(
dataHandler.value.rememberDataPacket(packet, myNodeNum, false)
val bytes = packet.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", packet.dataType))
+ val actionName =
+ when (packet.dataType) {
+ PortNum.TEXT_MESSAGE_APP.value -> "message_send"
+ PortNum.WAYPOINT_APP.value -> "waypoint_send"
+ else -> "data_send"
+ }
+ analytics.trackAction(actionName, mapOf("port_num" to packet.dataType, "num_bytes" to bytes.size))
}
override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) {
@@ -81,6 +88,7 @@ internal class MessagingControllerImpl(
)
.apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: NodeAddress.ID_LOCAL }
commandSender.sendData(dataPacket)
+ analytics.trackAction("reaction_send")
val user = nodeManager.nodeDBbyNodeNum[myNum]?.user ?: User(id = nodeManager.getMyId())
packetRepository.value.insertReaction(
Reaction(

diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts
index 85fa8eab2f..e04f185c02 100644
--- a/feature/firmware/build.gradle.kts
+++ b/feature/firmware/build.gradle.kts
@@ -38,6 +38,7 @@ kotlin {
implementation(projects.core.navigation)
implementation(projects.core.network)
implementation(projects.core.prefs)
+ implementation(projects.core.repository)
implementation(libs.meshtastic.protobufs)
implementation(projects.core.service)
implementation(projects.core.resources)

diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
index 79d99dd204..3a8672d6b3 100644
--- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
+++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt
@@ -55,6 +55,7 @@ import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
@@ -113,6 +114,7 @@ class FirmwareUpdateViewModel(
private val fileHandler: FirmwareFileHandler,
private val applicationScope: ApplicationCoroutineScope,
private val hiddenFeaturesUnlock: HiddenFeaturesUnlock,
+ private val analytics: PlatformAnalytics,
) : ViewModel() {
private val _state = MutableStateFlow<FirmwareUpdateState>(FirmwareUpdateState.Idle)
@@ -321,6 +323,22 @@ class FirmwareUpdateViewModel(
fun startUpdate() {
val currentState = _state.value as? FirmwareUpdateState.Ready ?: return
val release = currentState.release ?: return
+ // Explicit mapping instead of class-name reflection: FirmwareUpdateMethod is R8-obfuscated in release.
+ val updateMethod =
+ when (currentState.updateMethod) {
+ FirmwareUpdateMethod.Usb -> "usb"
+ FirmwareUpdateMethod.Ble -> "ble"
+ FirmwareUpdateMethod.Wifi -> "wifi"
+ FirmwareUpdateMethod.Unknown -> "unknown"
+ }
+ analytics.trackAction(
+ "firmware_update_start",
+ mapOf(
+ "update_method" to updateMethod,
+ "is_recovery" to currentState.isRecovery,
+ "release_version" to release.id,
+ ),
+ )
if (currentState.isRecovery) {
startRecoveryUpdate(currentState, release)
} else {

diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
index 483cc7bb07..df60d59a70 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt
@@ -115,6 +115,7 @@ class FirmwareUpdateIntegrationTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
+ mock(MockMode.autofill),
)
@Test

diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
index 52f34a1b9a..ef6581810f 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
+++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt
@@ -138,6 +138,7 @@ class FirmwareUpdateViewModelTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
hiddenFeaturesUnlock,
+ mock(MockMode.autofill),
)
@Test

diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
index fba8e1a7fe..59058b31dd 100644
--- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
+++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt
@@ -137,6 +137,7 @@ class FirmwareUpdateViewModelFileTest {
fileHandler,
TestApplicationCoroutineScope(testDispatcher),
HiddenFeaturesUnlock(),
+ mock(MockMode.autofill),
)
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
index aff51ae622..f2420e8858 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt
@@ -25,6 +25,7 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
@@ -47,6 +48,7 @@ class CommonNodeRequestActions
constructor(
private val radioController: RadioController,
private val snackbarManager: SnackbarManager,
+ private val analytics: PlatformAnalytics,
) : NodeRequestActions {
private val _lastTracerouteTime = MutableStateFlow<Long?>(null)
@@ -62,6 +64,7 @@ constructor(
override suspend fun requestUserInfo(destNum: Int, longName: String) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
+ analytics.trackAction("user_info_request")
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName))
}
@@ -76,6 +79,7 @@ constructor(
override suspend fun requestPosition(destNum: Int, longName: String, position: Position) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
+ analytics.trackAction("position_request")
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName))
}
@@ -83,6 +87,7 @@ constructor(
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.generatePacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
+ analytics.trackAction("telemetry_request", mapOf("telemetry_type" to type.name))
val typeRes =
when (type) {
@@ -102,6 +107,7 @@ constructor(
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.generatePacketId()
radioController.requestTraceroute(packetId, destNum)
+ analytics.trackAction("traceroute_request")
_lastTracerouteTime.value = nowMillis
showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName))
}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
index dc6e577500..7736de583a 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt
@@ -23,6 +23,7 @@ import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.favorite
@@ -46,6 +47,7 @@ constructor(
private val nodeRepository: NodeRepository,
private val radioController: RadioController,
private val alertManager: AlertManager,
+ private val analytics: PlatformAnalytics,
) {
open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) {
alertManager.showAlert(
@@ -114,6 +116,7 @@ constructor(
open suspend fun setFavorite(nodeNum: Int, favorite: Boolean) {
radioController.setFavorite(nodeNum, favorite)
+ analytics.trackAction("node_favorite", mapOf("favorite" to favorite))
}
open suspend fun setNodeNotes(nodeNum: Int, notes: String) {

diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
index fcfb7ce58b..7172e65e52 100644
--- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
+++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt
@@ -45,6 +45,7 @@ class NodeManagementActionsTest {
nodeRepository = nodeRepository,
radioController = radioController,
alertManager = alertManager,
+ analytics = mock(MockMode.autofill),
)
@Test
@@ -76,6 +77,7 @@ class NodeManagementActionsTest {
nodeRepository = nodeRepository,
radioController = radioController,
alertManager = realAlertManager,
+ analytics = mock(MockMode.autofill),
)
val node = Node(num = 123, user = User(long_name = "Test Node"))
var afterRemoveCalled = false

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt
index 093a670502..78ade787a7 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt
@@ -84,8 +84,10 @@ class ChannelViewModel(
}
/** Set the radio config (also updates our saved copy in preferences). */
- fun setChannels(channelSet: ChannelSet) =
- safeLaunch(tag = "setChannels") { importChannelSet(channelSet, radioController, radioConfigRepository) }
+ fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") {
+ importChannelSet(channelSet, radioController, radioConfigRepository)
+ analytics.trackAction("channel_update", mapOf("num_channels" to channelSet.settings.size))
+ }
// Set the radio config (also updates our saved copy in preferences)
fun setConfig(config: Config) {
@@ -94,6 +96,7 @@ class ChannelViewModel(
fun trackShare() {
analytics.track("share", DataPair("content_type", "channel"))
+ analytics.trackAction("channel_share")
}
private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) {

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 29bb17745f..ea2696719b 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -70,6 +70,7 @@ import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.NodeRestartTracker
import org.meshtastic.core.repository.PacketRepository
+import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.SecurityKeyBackupStore
import org.meshtastic.core.repository.ServiceRepository
@@ -161,6 +162,7 @@ open class RadioConfigViewModel(
private val securityKeyBackupStore: SecurityKeyBackupStore,
private val snackbarManager: SnackbarManager,
private val nodeRestartTracker: NodeRestartTracker,
+ private val analytics: PlatformAnalytics,
) : ViewModel() {
val lockdownTokenInfo = serviceRepository.lockdownTokenInfo
@@ -590,8 +592,11 @@ open class RadioConfigViewModel(
private fun sendAdminRequest(destNum: Int) {
val route = radioConfigState.value.route
+ val isLocal = radioConfigState.value.isLocal
_radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP)
+ analytics.trackAction("admin_action", mapOf("route" to route.lowercase(), "is_remote" to !isLocal))
+
val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites
when (route) {

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
index d22b83362c..620b7858ed 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt
@@ -257,7 +257,10 @@ fun ChannelScreen(
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
},
- onClickShare = { showShareDialog = true },
+ onClickShare = {
+ viewModel.trackShare()
+ showShareDialog = true
+ },
)
}
item {

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt
index 4de314fbea..cedc8f9455 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt
@@ -142,6 +142,7 @@ class ProfileRoundTripTest {
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
+ analytics = mock(MockMode.autofill),
securityKeyBackupStore = securityKeyBackupStore,
snackbarManager = snackbarManager,
nodeRestartTracker = nodeRestartTracker,

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
index 5415317d46..51289a01f6 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -184,6 +184,7 @@ class RadioConfigViewModelTest {
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
+ analytics = mock(MockMode.autofill),
)
@Test
@@ -975,6 +976,7 @@ class RadioConfigViewModelTest {
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
+ analytics = mock(MockMode.autofill),
)
assertEquals(456, viewModel.destNode.value?.num)
}

Served by rngit 1.4.2 - Generated in 0.32s